--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 0795d229c831df20b85d5ebd2c4e3f018349670b
Parents : 76e5504
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-05T00:33:13-05:00
feat(rnode_support): update Android RNodeInterface handling by adding support for TCP connections and improving error management for missing modules
Changes
6 files changed, 607 insertions(+), 58 deletions(-)
Diff
diff --git a/android/app/build.gradle b/android/app/build.gradle
index ee00c1de..354c37ea 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -11,6 +11,7 @@ def pythonTempDir = new File(buildDir, "python-tmp")
def vendorWheelDir = new File(rootProject.projectDir, "vendor")
def lxstPatchedWheel = new File(rootProject.projectDir, "vendor/lxst-0.4.8-py3-none-any.whl")
def bleakPatchedWheel = new File(rootProject.projectDir, "vendor/bleak-3.0.2-py3-none-any.whl")
+def rnsPatchedWheel = new File(rootProject.projectDir, "vendor/rns-1.3.7-py3-none-any.whl")
def allAndroidAbis = ["arm64-v8a", "x86_64", "armeabi-v7a"]
def selectedAndroidAbis = (
project.findProperty("meshchatxAbis")
@@ -227,7 +228,14 @@ chaquopy {
options "--find-links", vendorWheelDir.absolutePath
install "packaging>=23"
install "aiohttp==3.14.1"
- install "rns>=1.3.7"
+ if (!rnsPatchedWheel.exists()) {
+ throw new org.gradle.api.GradleException("Missing patched RNS wheel at ${rnsPatchedWheel}")
+ }
+ // Patched so RNS's Android RNodeInterface never calls RNS.panic() (os._exit)
+ // when usbserial4a/jnius are missing. RNode over TCP needs neither and always
+ // works; serial/BLE/classic-Bluetooth now fail gracefully instead of crashing
+ // the app. See scripts/build-android-wheels-local.sh.
+ install rnsPatchedWheel.absolutePath
install "lxmf>=1.0.1"
install "numpy==1.26.2"
install "chaquopy-libcodec2==1.2.0"
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 9fa317f8..4997d70a 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -853,7 +853,7 @@ class ReticulumMeshChat:
disable_rnode_interfaces_in_config,
)
- return disable_rnode_interfaces_in_config(config_path)
+ return disable_rnode_interfaces_in_config(config_path, is_android=True)
def _ensure_reticulum_config(self, materialize: bool = True):
"""Normalize ``reticulum_config_dir`` and optionally ensure a ``config`` file exists.
@@ -899,8 +899,10 @@ class ReticulumMeshChat:
from meshchatx.src.backend.rnode_support import (
guard_invalid_rnode_txpower_in_config,
guard_rnode_interfaces_on_android,
+ normalize_rnode_tcp_host_in_config,
)
+ normalize_rnode_tcp_host_in_config(config_path)
guard_rnode_interfaces_on_android(config_path)
guard_invalid_rnode_txpower_in_config(config_path)
@@ -5145,20 +5147,41 @@ class ReticulumMeshChat:
# update interface details
interface_details["type"] = interface_type
- if interface_type in (
- "RNodeInterface",
- "RNodeIPInterface",
- "RNodeMultiInterface",
- ):
- from meshchatx.src.backend.rnode_support import rnode_serial_supported
+ if interface_type == "RNodeMultiInterface":
+ # RNS has no Android-specific implementation of RNodeMultiInterface,
+ # so it always crashes on Android regardless of transport.
+ if _is_chaquopy_android():
+ return web.json_response(
+ {
+ "message": (
+ "RNodeMultiInterface is not supported on Android "
+ "(Reticulum has no Android-specific implementation of it)."
+ ),
+ },
+ status=422,
+ )
+
+ elif interface_type == "RNodeInterface":
+ # RNodeIPInterface always maps to an RNodeInterface with a tcp://
+ # port, which needs no native Android modules and always works.
+ from meshchatx.src.backend.rnode_support import (
+ rnode_transport_supported,
+ )
- if not rnode_serial_supported():
+ probe_interface = {
+ "port": data.get("port"),
+ "allow_bluetooth": data.get("allow_bluetooth"),
+ }
+ if not rnode_transport_supported(
+ probe_interface,
+ is_android=_is_chaquopy_android(),
+ ):
return web.json_response(
{
"message": (
- "RNode serial and Bluetooth are not available on this device. "
- "On Android, the app must include usbserial4a and jnius "
- "(see MeshChatX issue #6)."
+ "This RNode connection type is not available on this device. "
+ "On Android, USB serial and Bluetooth need usbserial4a and jnius "
+ "(see MeshChatX issue #6); RNode over IP (TCP) is unaffected."
),
},
status=422,
@@ -5548,6 +5571,7 @@ class ReticulumMeshChat:
status=422,
)
+ interface_tcp_host = None
if str(interface_port).strip().lower().startswith("tcp://"):
interface_port = InterfaceEditor.normalize_rnode_tcp_port(
str(interface_port),
@@ -5560,6 +5584,7 @@ class ReticulumMeshChat:
},
status=422,
)
+ interface_tcp_host = host_part
# ensure frequency provided
interface_frequency = data.get("frequency")
@@ -5616,6 +5641,14 @@ class ReticulumMeshChat:
# set required RNodeInterface options
interface_details["port"] = interface_port
+ if interface_tcp_host is not None:
+ # RNS's Android-specific RNodeInterface reads tcp_host as its
+ # own config key instead of parsing it out of port like the
+ # desktop implementation does, so both must be set for RNode
+ # over IP to work on Android.
+ interface_details["tcp_host"] = interface_tcp_host
+ else:
+ interface_details.pop("tcp_host", None)
interface_details["frequency"] = (
InterfaceEditor.coerce_rnode_frequency_hz(
interface_frequency,
diff --git a/meshchatx/src/backend/rnode_support.py b/meshchatx/src/backend/rnode_support.py
index e047e468..35a9345d 100644
--- a/meshchatx/src/backend/rnode_support.py
+++ b/meshchatx/src/backend/rnode_support.py
@@ -1,6 +1,15 @@
# SPDX-License-Identifier: 0BSD
-"""RNode USB serial / BLE UART support checks for desktop and Android."""
+"""RNode USB serial / Bluetooth / BLE support checks for desktop and Android.
+
+RNode over TCP ("RNode over IP") needs no native Android modules and works
+unconditionally, since RNS's Android RNodeInterface is patched (see
+scripts/build-android-wheels-local.sh) to stop hard-crashing the process when
+usbserial4a/jnius are missing. Serial and classic-Bluetooth ports need
+usbserial4a + jnius. BLE (ble://) ports need able. This module lets the rest
+of the app tell which RNode config entries can actually be brought up on the
+current build, so only the genuinely unsupported ones get disabled.
+"""
from __future__ import annotations
@@ -8,6 +17,8 @@ import logging
logger = logging.getLogger(__name__)
+_TRUE_STRINGS = ("true", "yes", "1", "on")
+
def _is_chaquopy_android() -> bool:
try:
@@ -19,9 +30,7 @@ def _is_chaquopy_android() -> bool:
def android_usbserial4a_available() -> bool:
- """True when Chaquopy can import usbserial4a (RNS RNode on Android)."""
- if not _is_chaquopy_android():
- return False
+ """True when usbserial4a can be imported (RNode serial/Bluetooth-classic on Android)."""
try:
import usbserial4a # noqa: F401
except ImportError:
@@ -30,9 +39,13 @@ def android_usbserial4a_available() -> bool:
def android_jnius_available() -> bool:
- """True when Chaquopy can import jnius (pyjnius)."""
- if not _is_chaquopy_android():
- return False
+ """True when jnius (pyjnius) can be imported.
+
+ RNS's Android-specific RNodeInterface needs jnius for USB serial and
+ classic Bluetooth (RFCOMM) access. Chaquopy does not ship pyjnius under
+ the importable name "jnius" unless bundled explicitly (e.g. via a
+ compatibility shim), so this is normally unavailable.
+ """
try:
import jnius # noqa: F401
except ImportError:
@@ -40,6 +53,15 @@ def android_jnius_available() -> bool:
return True
+def android_able_available() -> bool:
+ """True when able can be imported (BLE GATT support for RNode ble:// on Android)."""
+ try:
+ import able # noqa: F401
+ except ImportError:
+ return False
+ return True
+
+
def desktop_serial_stack_available() -> bool:
try:
from serial.tools import list_ports # noqa: F401
@@ -49,14 +71,131 @@ def desktop_serial_stack_available() -> bool:
def rnode_serial_supported() -> bool:
- """Whether RNode serial and ble:// UART ports can be opened on this platform."""
+ """Whether RNode USB serial / classic-Bluetooth ports can be opened here.
+
+ This does not cover RNode over TCP (always supported) or ble:// (covered
+ by android_able_available() on Android).
+ """
if _is_chaquopy_android():
return android_usbserial4a_available() and android_jnius_available()
return desktop_serial_stack_available()
-def disable_rnode_interfaces_in_config(config_path: str) -> bool:
- """Disable enabled RNode* interfaces in a Reticulum config file.
+def _is_enabled(iface: dict) -> bool:
+ for key in ("interface_enabled", "enabled"):
+ if key in iface and str(iface.get(key, "")).lower() in _TRUE_STRINGS:
+ return True
+ return False
+
+
+def rnode_port_is_tcp(port: object) -> bool:
+ return str(port or "").strip().lower().startswith("tcp://")
+
+
+def rnode_port_is_ble(port: object) -> bool:
+ return str(port or "").strip().lower().startswith("ble://")
+
+
+def _rnode_iface_transport(iface: dict) -> str:
+ """Classify an RNodeInterface config entry's transport.
+
+ Returns one of "tcp", "ble", "bluetooth_classic", or "serial".
+ """
+ port = iface.get("port")
+ if rnode_port_is_tcp(port):
+ return "tcp"
+ if rnode_port_is_ble(port):
+ return "ble"
+ allow_bluetooth = str(iface.get("allow_bluetooth", "")).lower() in _TRUE_STRINGS
+ if not port and allow_bluetooth:
+ return "bluetooth_classic"
+ return "serial"
+
+
+def rnode_transport_supported(iface: dict, *, is_android: bool | None = None) -> bool:
+ """Whether a specific RNodeInterface config entry can be brought up here.
+
+ RNode over TCP always works. Serial and classic-Bluetooth need
+ usbserial4a + jnius on Android. BLE needs able on Android. On desktop,
+ every transport relies on the regular pyserial/bleak stack.
+
+ ``is_android`` lets a caller that already determined the platform pass
+ that result through explicitly, instead of re-detecting it here.
+ """
+ if is_android is None:
+ is_android = _is_chaquopy_android()
+ if not is_android:
+ return desktop_serial_stack_available()
+
+ transport = _rnode_iface_transport(iface)
+ if transport == "tcp":
+ return True
+ if transport == "ble":
+ return android_able_available()
+ return android_usbserial4a_available() and android_jnius_available()
+
+
+def normalize_rnode_tcp_host_in_config(config_path: str) -> bool:
+ """Backfill tcp_host for RNodeInterface entries configured with a tcp:// port.
+
+ RNS's desktop RNodeInterface derives tcp_host from a tcp:// port itself,
+ but the Android-specific implementation reads tcp_host as its own,
+ separate config key and never looks at port for that. Configs written or
+ hand-edited with only ``port = tcp://host:port`` therefore silently try
+ (and fail) to open the RNode as a serial device on Android. This keeps
+ both keys in sync regardless of how the entry was created, so RNode over
+ TCP works the same way on both platforms.
+
+ Returns True if any interfaces were modified.
+ """
+ import os
+
+ if not os.path.isfile(config_path):
+ return False
+ try:
+ from RNS.vendor.configobj import ConfigObj
+
+ cfg = ConfigObj(config_path)
+ except Exception:
+ return False
+
+ modified = False
+ interfaces = cfg.get("interfaces")
+ if not isinstance(interfaces, dict):
+ return False
+ for _iface_name, iface in interfaces.items():
+ if not isinstance(iface, dict):
+ continue
+ if iface.get("type") != "RNodeInterface":
+ continue
+ port = iface.get("port")
+ if not rnode_port_is_tcp(port):
+ continue
+ host_part = str(port).strip()[len("tcp://") :].strip().strip(":")
+ if not host_part:
+ continue
+ if str(iface.get("tcp_host", "")).strip() != host_part:
+ iface["tcp_host"] = host_part
+ modified = True
+ if modified:
+ try:
+ cfg.write()
+ except Exception:
+ pass
+ return modified
+
+
+def disable_rnode_interfaces_in_config(
+ config_path: str,
+ *,
+ is_android: bool | None = None,
+) -> bool:
+ """Disable RNode* interfaces in a Reticulum config file that can't run here.
+
+ RNode over TCP is left enabled since it needs no native Android modules.
+ RNodeMultiInterface has no Android-specific implementation upstream and is
+ always disabled on Android. Serial, classic-Bluetooth, and BLE entries are
+ disabled only when their required native module isn't available.
Returns True if any interfaces were disabled.
"""
@@ -71,6 +210,9 @@ def disable_rnode_interfaces_in_config(config_path: str) -> bool:
except Exception:
return False
+ if is_android is None:
+ is_android = _is_chaquopy_android()
+
modified = False
interfaces = cfg.get("interfaces")
if not isinstance(interfaces, dict):
@@ -79,15 +221,19 @@ def disable_rnode_interfaces_in_config(config_path: str) -> bool:
if not isinstance(iface, dict):
continue
iface_type = iface.get("type", "")
- if isinstance(iface_type, str) and iface_type.startswith("RNode"):
- if str(iface.get("interface_enabled", "")).lower() in (
- "true",
- "yes",
- "1",
- "on",
- ):
- iface["interface_enabled"] = "false"
- modified = True
+ if not isinstance(iface_type, str) or not iface_type.startswith("RNode"):
+ continue
+ if not _is_enabled(iface):
+ continue
+
+ if iface_type == "RNodeMultiInterface":
+ should_disable = is_android
+ else:
+ should_disable = not rnode_transport_supported(iface, is_android=is_android)
+
+ if should_disable:
+ iface["interface_enabled"] = "false"
+ modified = True
if modified:
try:
cfg.write()
@@ -136,12 +282,7 @@ def guard_invalid_rnode_txpower_in_config(config_path: str) -> bool:
continue
if not _rnode_interface_has_invalid_txpower(iface):
continue
- if str(iface.get("interface_enabled", "")).lower() not in (
- "true",
- "yes",
- "1",
- "on",
- ):
+ if str(iface.get("interface_enabled", "")).lower() not in _TRUE_STRINGS:
continue
iface["interface_enabled"] = "false"
modified = True
@@ -166,21 +307,19 @@ def guard_invalid_rnode_txpower_in_config(config_path: str) -> bool:
def guard_rnode_interfaces_on_android(config_path: str) -> bool:
- """On Android without usbserial4a/jnius, disable RNode interfaces to avoid startup crashes."""
+ """On Android, disable RNode interfaces that can't be brought up on this build.
+
+ RNode over TCP is unaffected. Serial, classic-Bluetooth, and BLE entries
+ are disabled only when their required native module isn't bundled, and
+ RNodeMultiInterface is always disabled since RNS has no Android-specific
+ implementation of it.
+ """
if not _is_chaquopy_android():
return False
- if rnode_serial_supported():
- return False
- missing = []
- if not android_usbserial4a_available():
- missing.append("usbserial4a")
- if not android_jnius_available():
- missing.append("jnius")
- disabled = disable_rnode_interfaces_in_config(config_path)
+ disabled = disable_rnode_interfaces_in_config(config_path, is_android=True)
if disabled:
logger.warning(
- "RNode interfaces were disabled because %s is not installed on this build. "
- "Rebuild the Android app with the missing module(s) or remove RNode entries from config.",
- " and ".join(missing) if missing else "a required module",
+ "One or more RNode interfaces were disabled because their transport "
+ "is not supported on this Android build. RNode over TCP is unaffected.",
)
return disabled
diff --git a/scripts/build-android-wheels-local.sh b/scripts/build-android-wheels-local.sh
index 54e70811..b22c34d3 100755
--- a/scripts/build-android-wheels-local.sh
+++ b/scripts/build-android-wheels-local.sh
@@ -11,9 +11,11 @@ This script:
3) Builds pycodec2 Android wheels with Chaquopy's build-wheel tool
4) Optionally patches LXST wheel metadata for local Android constraints
5) Vendors the bleak pure-python wheel from PyPI
-6) Builds every recipe under android/chaquopy-recipes/ for each requested
+6) Patches the rns wheel so its Android RNodeInterface never calls
+ RNS.panic() (os._exit) when usbserial4a/jnius are missing
+7) Builds every recipe under android/chaquopy-recipes/ for each requested
ABI (currently: cryptography, miniaudio)
-7) Copies outputs to android/vendor
+8) Copies outputs to android/vendor
Usage:
scripts/build-android-wheels-local.sh [options]
@@ -28,7 +30,9 @@ Options:
--numpy-version V NumPy version used during pycodec2 build (default: 1.26.2)
--lxst-version V LXST wheel version for metadata patch (default: 0.4.8)
--bleak-version V bleak pure-python wheel version to vendor (default: 3.0.2)
+ --rns-version V rns wheel version to patch (default: 1.3.7)
--no-lxst-patch Skip LXST metadata patch
+ --no-rns-patch Skip RNS Android RNodeInterface patch
--only-recipes LIST Comma-separated recipe directory names under
android/chaquopy-recipes to build. When set, the
NumPy, pycodec2/chaquopy-libcodec2 and LXST steps
@@ -56,7 +60,9 @@ LIBCODEC2_VERSION="1.2.0"
NUMPY_VERSION="1.26.2"
LXST_VERSION="0.4.8"
BLEAK_VERSION="3.0.2"
+RNS_VERSION="1.3.7"
PATCH_LXST="1"
+PATCH_RNS="1"
ONLY_RECIPES=""
WORK_DIR="${ROOT_DIR}/.local/chaquopy-build-wheel"
OUT_DIR="${ROOT_DIR}/android/vendor"
@@ -99,10 +105,18 @@ while [[ $# -gt 0 ]]; do
BLEAK_VERSION="${2:?missing value for --bleak-version}"
shift 2
;;
+ --rns-version)
+ RNS_VERSION="${2:?missing value for --rns-version}"
+ shift 2
+ ;;
--no-lxst-patch)
PATCH_LXST="0"
shift
;;
+ --no-rns-patch)
+ PATCH_RNS="0"
+ shift
+ ;;
--only-recipes)
ONLY_RECIPES="${2:?missing value for --only-recipes}"
shift 2
@@ -793,6 +807,105 @@ PY
fi
fi
+if [[ "${PATCH_RNS}" == "1" && -z "${ONLY_RECIPES}" ]]; then
+ echo "Fetching and patching rns ${RNS_VERSION} for Android"
+ RNS_TMP_DIR="$(mktemp -d)"
+
+ "${VENV_DIR}/bin/pip" download \
+ --only-binary=:all: \
+ --no-deps \
+ "rns==${RNS_VERSION}" \
+ --dest "${RNS_TMP_DIR}" \
+ --index-url https://pypi.org/simple
+
+ RNS_WHEEL="$(ls "${RNS_TMP_DIR}"/rns-"${RNS_VERSION}"-py3-none-any.whl)"
+ PATCHED_RNS_WHEEL="${OUT_DIR}/rns-${RNS_VERSION}-py3-none-any.whl"
+
+ # RNS's Android-specific RNodeInterface calls RNS.panic() (os._exit) when
+ # usbserial4a/jnius aren't importable, even for RNode over TCP, which
+ # needs neither. Since Reticulum.py also panics on any exception raised
+ # from an interface's __init__, the fix is to never raise/panic there:
+ # leave serial/bt_manager as None and let the transport-specific code
+ # (open_port/configure_device) fail into RNodeInterface's own existing
+ # reconnect-loop instead of crashing the whole app.
+ "${VENV_DIR}/bin/python" - <<PY
+import zipfile
+from pathlib import Path
+
+src = Path("${RNS_WHEEL}")
+dst = Path("${PATCHED_RNS_WHEEL}")
+
+start_marker = (
+ " import importlib.util\n"
+ " if RNS.vendor.platformutils.is_android():\n"
+)
+end_marker = (
+ ' raise SystemError("Android-specific interface was used on non-Android OS")\n'
+)
+
+new_block = ''' import importlib.util
+ serial = None
+ self.bt_manager = None
+ if RNS.vendor.platformutils.is_android():
+ self.on_android = True
+ if importlib.util.find_spec('usbserial4a') != None:
+ if importlib.util.find_spec('jnius') == None:
+ RNS.log("Could not load jnius API wrapper for Android. USB serial and classic Bluetooth are unavailable for "+str(name)+".", RNS.LOG_ERROR)
+ RNS.log("This probably means you are trying to use an USB-based interface from within Termux or similar, or", RNS.LOG_ERROR)
+ RNS.log("that the running app does not bundle jnius. RNode over TCP is not affected by this.", RNS.LOG_ERROR)
+
+ else:
+ from usbserial4a import serial4a as serial
+ self.parity = "N"
+
+ self.bt_target_device_name = target_device_name
+ self.bt_target_device_address = target_device_address
+ if allow_bluetooth:
+ try:
+ self.bt_manager = AndroidBluetoothManager(
+ owner = self,
+ target_device_name = self.bt_target_device_name,
+ target_device_address = self.bt_target_device_address
+ )
+ except Exception as e:
+ RNS.log("Could not initialise classic Bluetooth support: "+str(e), RNS.LOG_ERROR)
+ self.bt_manager = None
+
+ else:
+ RNS.log("Could not load USB serial module for Android. USB serial RNode transport is unavailable for "+str(name)+".", RNS.LOG_ERROR)
+ RNS.log("You can install this module by issuing: pip install usbserial4a", RNS.LOG_ERROR)
+ else:
+ raise SystemError("Android-specific interface was used on non-Android OS")
+'''
+
+def patch_rnode_interface(data):
+ text = data.decode("utf-8")
+ start_idx = text.index(start_marker)
+ end_idx = text.index(end_marker, start_idx) + len(end_marker)
+ return (text[:start_idx] + new_block + text[end_idx:]).encode("utf-8")
+
+patched_target = "RNS/Interfaces/Android/RNodeInterface.py"
+found = False
+with zipfile.ZipFile(src, "r") as zin, zipfile.ZipFile(dst, "w", compression=zipfile.ZIP_DEFLATED) as zout:
+ for item in zin.infolist():
+ data = zin.read(item.filename)
+ if item.filename == patched_target:
+ data = patch_rnode_interface(data)
+ found = True
+ zout.writestr(item, data)
+
+if not found:
+ raise SystemExit(f"Could not find {patched_target} in {src}")
+PY
+
+ rm -rf "${RNS_TMP_DIR}"
+
+ if ! ls "${PATCHED_RNS_WHEEL}" >/dev/null 2>&1; then
+ echo "Expected rns-${RNS_VERSION}-py3-none-any.whl in ${OUT_DIR}" >&2
+ exit 1
+ fi
+fi
+
fix_wheel_libpython_needed() {
local wheel="$1"
local python_soname="$2"
diff --git a/tests/backend/test_interface_options.py b/tests/backend/test_interface_options.py
index 4c52acac..0a3c9a74 100644
--- a/tests/backend/test_interface_options.py
+++ b/tests/backend/test_interface_options.py
@@ -489,6 +489,85 @@ async def test_rnode_tcp_over_ip_normalizes_to_host_only(temp_dir):
body = json.loads(response.body)
assert response.status == 200, body
assert config["interfaces"]["RNodeWiFi"]["port"] == "tcp://192.168.4.1"
+ # RNS's Android-specific RNodeInterface reads tcp_host as its own
+ # config key instead of parsing it out of port like desktop does.
+ assert config["interfaces"]["RNodeWiFi"]["tcp_host"] == "192.168.4.1"
+
+
+@pytest.mark.asyncio
+async def test_rnode_over_ip_allowed_on_android_without_usbserial4a_or_jnius(temp_dir):
+ """RNode over TCP needs no native Android modules and must not be blocked."""
+ config = ConfigDict({"reticulum": {}, "interfaces": {}})
+
+ async with make_app(temp_dir, config) as handler:
+ with patch("meshchatx.meshchat._is_chaquopy_android", return_value=True):
+ payload = {
+ "name": "RNodeWiFi",
+ "type": "RNodeIPInterface",
+ "port": "tcp://192.168.4.1:7633",
+ "frequency": 868000000,
+ "bandwidth": 125000,
+ "txpower": 7,
+ "spreadingfactor": 8,
+ "codingrate": 5,
+ }
+ response = await handler(make_request(payload))
+ body = json.loads(response.body)
+ assert response.status == 200, body
+ assert config["interfaces"]["RNodeWiFi"]["tcp_host"] == "192.168.4.1"
+
+
+@pytest.mark.asyncio
+async def test_rnode_serial_blocked_on_android_without_usbserial4a_or_jnius(temp_dir):
+ config = ConfigDict({"reticulum": {}, "interfaces": {}})
+
+ async with make_app(temp_dir, config) as handler:
+ with patch("meshchatx.meshchat._is_chaquopy_android", return_value=True):
+ payload = {
+ "name": "Radio",
+ "type": "RNodeInterface",
+ "port": "/dev/ttyUSB0",
+ "frequency": 868000000,
+ "bandwidth": 125000,
+ "txpower": 7,
+ "spreadingfactor": 8,
+ "codingrate": 5,
+ }
+ response = await handler(make_request(payload))
+ body = json.loads(response.body)
+ assert response.status == 422, body
+ assert "RNode over IP" in body["message"]
+ assert "Radio" not in config["interfaces"]
+
+
+@pytest.mark.asyncio
+async def test_rnode_multi_interface_blocked_on_android(temp_dir):
+ """RNS has no Android-specific implementation of RNodeMultiInterface."""
+ config = ConfigDict({"reticulum": {}, "interfaces": {}})
+
+ async with make_app(temp_dir, config) as handler:
+ with patch("meshchatx.meshchat._is_chaquopy_android", return_value=True):
+ payload = {
+ "name": "MultiRadio",
+ "type": "RNodeMultiInterface",
+ "port": "/dev/ttyUSB0",
+ "sub_interfaces": [
+ {
+ "name": "vport0",
+ "frequency": 868000000,
+ "bandwidth": 125000,
+ "txpower": 7,
+ "spreadingfactor": 8,
+ "codingrate": 5,
+ "vport": 0,
+ },
+ ],
+ }
+ response = await handler(make_request(payload))
+ body = json.loads(response.body)
+ assert response.status == 422, body
+ assert "not supported on Android" in body["message"]
+ assert "MultiRadio" not in config["interfaces"]
@pytest.mark.asyncio
diff --git a/tests/backend/test_rnode_support.py b/tests/backend/test_rnode_support.py
index 0fd66e28..01fcea38 100644
--- a/tests/backend/test_rnode_support.py
+++ b/tests/backend/test_rnode_support.py
@@ -1,9 +1,66 @@
# SPDX-License-Identifier: 0BSD
+import pytest
+
from meshchatx.src.backend import rnode_support
+def test_normalize_rnode_tcp_host_backfills_from_port(tmp_path):
+ """RNS's Android RNodeInterface reads tcp_host as its own config key.
+
+ Configs written with only ``port = tcp://host:port`` (which is all the
+ desktop RNodeInterface needs) silently try to open the RNode as a serial
+ device on Android unless tcp_host is also present.
+ """
+ config_path = tmp_path / "config"
+ config_path.write_text(
+ """[interfaces]
+ [[RNode TCP]]
+ type = RNodeInterface
+ interface_enabled = True
+ port = tcp://192.0.2.1:4242
+""",
+ encoding="utf-8",
+ )
+
+ assert rnode_support.normalize_rnode_tcp_host_in_config(str(config_path)) is True
+ text = config_path.read_text(encoding="utf-8")
+ assert "tcp_host = 192.0.2.1:4242" in text
+
+
+def test_normalize_rnode_tcp_host_leaves_non_tcp_entries_alone(tmp_path):
+ config_path = tmp_path / "config"
+ config_path.write_text(
+ """[interfaces]
+ [[RNode Serial]]
+ type = RNodeInterface
+ interface_enabled = True
+ port = /dev/ttyUSB0
+""",
+ encoding="utf-8",
+ )
+
+ assert rnode_support.normalize_rnode_tcp_host_in_config(str(config_path)) is False
+ assert "tcp_host" not in config_path.read_text(encoding="utf-8")
+
+
+def test_normalize_rnode_tcp_host_is_idempotent(tmp_path):
+ config_path = tmp_path / "config"
+ config_path.write_text(
+ """[interfaces]
+ [[RNode TCP]]
+ type = RNodeInterface
+ interface_enabled = True
+ port = tcp://192.0.2.1:4242
+ tcp_host = 192.0.2.1:4242
+""",
+ encoding="utf-8",
+ )
+
+ assert rnode_support.normalize_rnode_tcp_host_in_config(str(config_path)) is False
+
+
def test_guard_disables_rnode_when_usbserial4a_missing(tmp_path, monkeypatch):
config_path = tmp_path / "config"
config_path.write_text(
@@ -11,7 +68,7 @@ def test_guard_disables_rnode_when_usbserial4a_missing(tmp_path, monkeypatch):
[[RNode Serial]]
type = RNodeInterface
interface_enabled = True
- port = ble://aa:bb:cc:dd:ee:ff
+ port = /dev/ttyUSB0
""",
encoding="utf-8",
)
@@ -24,19 +81,17 @@ def test_guard_disables_rnode_when_usbserial4a_missing(tmp_path, monkeypatch):
def test_guard_disables_rnode_when_jnius_missing(tmp_path, monkeypatch):
- """usbserial4a alone is not enough: RNS's Android RNodeInterface also needs jnius.
+ """usbserial4a alone is not enough for serial/classic-Bluetooth ports.
- Chaquopy builds that bundle usbserial4a but not jnius still hit
- RNS.panic() (os._exit) for any RNode port type (serial, tcp://, ble://),
- since the jnius check runs before the transport is selected.
+ RNS's Android RNodeInterface also needs jnius for those transports.
"""
config_path = tmp_path / "config"
config_path.write_text(
"""[interfaces]
- [[RNode TCP]]
+ [[RNode Serial]]
type = RNodeInterface
interface_enabled = True
- port = tcp://192.0.2.1:4242
+ port = /dev/ttyUSB0
""",
encoding="utf-8",
)
@@ -55,12 +110,108 @@ def test_guard_keeps_rnode_when_usbserial4a_and_jnius_available(tmp_path, monkey
[[RNode Serial]]
type = RNodeInterface
interface_enabled = True
+ port = /dev/ttyUSB0
+""",
+ encoding="utf-8",
+ )
+ monkeypatch.setattr(rnode_support, "_is_chaquopy_android", lambda: True)
+ monkeypatch.setattr(rnode_support, "android_usbserial4a_available", lambda: True)
+ monkeypatch.setattr(rnode_support, "android_jnius_available", lambda: True)
+
+ assert rnode_support.guard_rnode_interfaces_on_android(str(config_path)) is False
+ assert "interface_enabled = True" in config_path.read_text(encoding="utf-8")
+
+
+def test_guard_keeps_tcp_rnode_enabled_even_without_usbserial4a_or_jnius(
+ tmp_path,
+ monkeypatch,
+):
+ """RNode over TCP needs no native Android modules and must stay enabled."""
+ config_path = tmp_path / "config"
+ config_path.write_text(
+ """[interfaces]
+ [[RNode TCP]]
+ type = RNodeInterface
+ interface_enabled = True
+ port = tcp://192.0.2.1:4242
+""",
+ encoding="utf-8",
+ )
+ monkeypatch.setattr(rnode_support, "_is_chaquopy_android", lambda: True)
+ monkeypatch.setattr(rnode_support, "android_usbserial4a_available", lambda: False)
+ monkeypatch.setattr(rnode_support, "android_jnius_available", lambda: False)
+
+ assert rnode_support.guard_rnode_interfaces_on_android(str(config_path)) is False
+ assert "interface_enabled = True" in config_path.read_text(encoding="utf-8")
+
+
+def test_guard_disables_ble_rnode_when_able_missing(tmp_path, monkeypatch):
+ config_path = tmp_path / "config"
+ config_path.write_text(
+ """[interfaces]
+ [[RNode BLE]]
+ type = RNodeInterface
+ interface_enabled = True
+ port = ble://aa:bb:cc:dd:ee:ff
+""",
+ encoding="utf-8",
+ )
+ monkeypatch.setattr(rnode_support, "_is_chaquopy_android", lambda: True)
+ monkeypatch.setattr(rnode_support, "android_able_available", lambda: False)
+
+ assert rnode_support.guard_rnode_interfaces_on_android(str(config_path)) is True
+ assert "interface_enabled = false" in config_path.read_text(encoding="utf-8")
+
+
+def test_guard_keeps_ble_rnode_when_able_available(tmp_path, monkeypatch):
+ config_path = tmp_path / "config"
+ config_path.write_text(
+ """[interfaces]
+ [[RNode BLE]]
+ type = RNodeInterface
+ interface_enabled = True
+ port = ble://aa:bb:cc:dd:ee:ff
+""",
+ encoding="utf-8",
+ )
+ monkeypatch.setattr(rnode_support, "_is_chaquopy_android", lambda: True)
+ monkeypatch.setattr(rnode_support, "android_able_available", lambda: True)
+
+ assert rnode_support.guard_rnode_interfaces_on_android(str(config_path)) is False
+ assert "interface_enabled = True" in config_path.read_text(encoding="utf-8")
+
+
+def test_guard_always_disables_rnode_multi_interface_on_android(tmp_path, monkeypatch):
+ """RNS has no Android-specific RNodeMultiInterface; it always crashes there."""
+ config_path = tmp_path / "config"
+ config_path.write_text(
+ """[interfaces]
+ [[RNode Multi]]
+ type = RNodeMultiInterface
+ interface_enabled = True
""",
encoding="utf-8",
)
monkeypatch.setattr(rnode_support, "_is_chaquopy_android", lambda: True)
monkeypatch.setattr(rnode_support, "android_usbserial4a_available", lambda: True)
monkeypatch.setattr(rnode_support, "android_jnius_available", lambda: True)
+ monkeypatch.setattr(rnode_support, "android_able_available", lambda: True)
+
+ assert rnode_support.guard_rnode_interfaces_on_android(str(config_path)) is True
+ assert "interface_enabled = false" in config_path.read_text(encoding="utf-8")
+
+
+def test_guard_skips_rnode_multi_interface_off_android(tmp_path, monkeypatch):
+ config_path = tmp_path / "config"
+ config_path.write_text(
+ """[interfaces]
+ [[RNode Multi]]
+ type = RNodeMultiInterface
+ interface_enabled = True
+""",
+ encoding="utf-8",
+ )
+ monkeypatch.setattr(rnode_support, "_is_chaquopy_android", lambda: False)
assert rnode_support.guard_rnode_interfaces_on_android(str(config_path)) is False
assert "interface_enabled = True" in config_path.read_text(encoding="utf-8")
@@ -86,6 +237,32 @@ def test_rnode_serial_supported_on_android_requires_both_modules(monkeypatch):
assert rnode_support.rnode_serial_supported() is True
+def test_rnode_transport_supported_tcp_always_true_on_android(monkeypatch):
+ monkeypatch.setattr(rnode_support, "_is_chaquopy_android", lambda: True)
+ monkeypatch.setattr(rnode_support, "android_usbserial4a_available", lambda: False)
+ monkeypatch.setattr(rnode_support, "android_jnius_available", lambda: False)
+ monkeypatch.setattr(rnode_support, "android_able_available", lambda: False)
+
+ assert (
+ rnode_support.rnode_transport_supported({"port": "tcp://example.org:4242"})
+ is True
+ )
+
+
+def test_rnode_transport_supported_bluetooth_classic_needs_usbserial_and_jnius(
+ monkeypatch,
+):
+ monkeypatch.setattr(rnode_support, "_is_chaquopy_android", lambda: True)
+ monkeypatch.setattr(rnode_support, "android_usbserial4a_available", lambda: True)
+ monkeypatch.setattr(rnode_support, "android_jnius_available", lambda: True)
+
+ iface = {"port": "", "allow_bluetooth": "true"}
+ assert rnode_support.rnode_transport_supported(iface) is True
+
+ monkeypatch.setattr(rnode_support, "android_jnius_available", lambda: False)
+ assert rnode_support.rnode_transport_supported(iface) is False
+
+
def test_guard_disables_rnode_with_invalid_txpower(tmp_path):
config_path = tmp_path / "config"
config_path.write_text(
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────